Skip to content

Land the ui v2 component port on js-toolkit v4 - #642

Draft
titouanmathis wants to merge 30 commits into
2.xfrom
feat/v2-component-port
Draft

Land the ui v2 component port on js-toolkit v4#642
titouanmathis wants to merge 30 commits into
2.xfrom
feat/v2-component-port

Conversation

@titouanmathis

@titouanmathis titouanmathis commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

This is the first step of @studiometa/ui v2: it lands the component port that was written and proved against @studiometa/js-toolkit v4 in the js-toolkit repository, migrates four families the port never covered, moves the whole workspace onto @studiometa/js-toolkit@4.0.0-alpha.1 (published under the next dist-tag), and replaces the test runner with Vitest browser mode on Playwright Chromium.

The port's own write-up — every deviation from the v1 sources, why it was made, and the size measurements — is packages/js-toolkit/migration/REPORT.md in studiometa/js-toolkit. The v4 API is documented at https://js-toolkit-v4.studiometa.dev.

The plan of record is V2-COMPONENTS-REVIEW.md, committed here as-is in the first commit. It outranks the port wherever the two disagree, and everything below is sourced from it.

Families

Family State Reason
Action (Action, ActionEvent, Target) Ported Keep
AnchorNav (AnchorNav, AnchorNavLink, AnchorNavTarget) Ported Keep — scrollspy pattern nothing else covers
Carousel (7 classes) Ported Keep
ClickOutside Ported Keep
Cursor Ported Keep. The generic-API redesign the review asks for is not in this PR
Data* (DataScope, DataBind, DataModel, DataComputed, DataEffect) Ported Keep. DataChannel and withGroup are replaced by one DataRegistry on core's signal() and provide/inject
Dialog Ported Keep
Disclosure / DisclosureGroup Migrated here Keep. No port existed. Events are namespaced — see below
Draggable Ported Keep
Fetch (Fetch, FetchShopifyPartial, FetchShopifySection) Ported Keep
Figure (AbstractFigure, AbstractFigureDynamic, Figure, FigureShopify, FigureTwicpics) Ported Keep
FigureVideo (FigureVideo, FigureVideoTwicpics) Ported Keep
Hoverable Ported Keep
Indexable Ported Keep. withIndex is gone: its whole body is the Indexable class now
InView (InView, InViewOnce) Ported Keep
LargeText Migrated here Keep. No port existed. The generic-API redesign the review asks for remains outstanding
LazyIncludeDefer Ported, renamed Keep as the lightweight primitive, renamed. Directory, class, config.name and events all follow: contentdefer-content, errordefer-error, alwaysdefer-always
Menu (Menu, MenuBtn, MenuList) Ported Keep
Prefetch (AbstractPrefetch, PrefetchWhenVisible, PrefetchWhenOverPrefetchOnInteraction) Ported, renamed Keep, renamed. The port already carried the rename
ScrollReveal Migrated here Keep. No port existed
AnchorScrollToScrollTo Ported, renamed Keep, renamed. The port already carried the rename; AnchorNavLink follows it
Sentinel Ported Keep as-is — it exposes the raw IntersectionObserverEntry that InView collapses away, and Sticky needs boundingClientRect.y
Slider (7 classes) Ported here Keep. The review's rewrite onto Indexable is deferred by decision, not overlooked
Sticky Ported Keep as-is — it coordinates stacked sticky elements, which CSS cannot
Timer (Timer, TimerProgress) Ported Keep
Toaster (Toast, Toaster) Ported Keep
Track (AbstractTrack, Track, TrackContext, TrackEvent, TrackShopify) Ported Keep
Transition / ViewTransition / withTransition Ported Keep. withTransition is back as a real mixin (js-toolkit REPORT gap 45); ViewTransition/scheduler.ts is deleted, core absorbed it
withDeprecation Rewritten Keep. Rewritten onto v4: $warn() on the diagnostic channel instead of console.warn behind isDev. It has no consumer left in v2
Accordion (Accordion, AccordionItem) Dropped Superseded by Disclosure + DisclosureGroup
Frame (6 classes + AbstractFrameTrigger) Dropped Superseded by Fetch
Modal / ModalWithTransition / Panel Dropped Superseded by Dialog
ScrollAnimation (7 classes), animationScrollWithEase, withScrollAnimationDebug Dropped Superseded by ui-motion
ImageGrid, Reinsurance, StyledModal, StyledPanel (Twig) Dropped Review round 3
withIndex Dropped Absorbed into the Indexable class
AbstractSliderChild Dropped Absorbed into SliderContext; its export subpath is removed
Tabs Deferred Review: rewrite. v1 source left in place, does not type-check against v4, and its spec is red on three real assertions
CircularMarquee Not migrated Review: keep, generic-API redesign pending. Out of scope for this round

27 families on v4 (23 ported from the js-toolkit exercise, Slider landed from it here, Disclosure/DisclosureGroup, ScrollReveal and LargeText migrated here from their v1 sources), 6 groups dropped, 2 families left on their v1 sources (Tabs deferred by decision, CircularMarquee out of scope).

The runner is a browser now

The suite ran under happy-dom through @happy-dom/global-registrator. That runner cannot answer the questions these components ask — no IntersectionObserver, no layout geometry, no computed styles, no document location, and none of the browser globals @studiometa/js-toolkit is entitled to use, reportError() among them. It is now Vitest browser mode on Playwright Chromium, the runner js-toolkit proves its own 1570 tests with.

The result: 169 failures became 3, and the flakiness is gone. Three consecutive full runs gave the identical 3 failed | 571 passed | 1 skipped (575), against a happy-dom band of 154–169 out of 539 that retry: 3 never settled.

The suite is two projects. chromium runs everything that touches the DOM. node runs the four export-surface specs that read the repository instead — the TypeScript compiler API, node:fs, node:child_process, import.meta.resolve — and cannot run in a page.

The previous round's two-project attempt hung; the cause is found and it was not the split. It was test.root: '..'. Pointing the root at packages/ is harmless for Node, which needs no dev server, but as a Vite server root it makes the dev server serve and crawl the whole monorepo — docs, playground, cdn, api, every package's node_modules — and the run never reaches the first test. The root is this package now. That also stops the suite silently collecting packages/eslint-plugin-ui's five rule-tester specs, which have their own test script in their own package and were only ever swept in by the wide root — the whole 539 → 534 test-count drop at that step.

Dependency discovery is off for a related reason: ui-motion, ui-mapbox and Tabs still import subpaths v4 removed, and the up-front scan turned one dead subpath into a failure of the entire run instead of the one spec that owns it.

Removed with the dependency: packages/tests/__utils__/happydom.ts in full — the three shims for platform invariants happy-dom breaks (window instanceof Window, HTMLCollection[Symbol.iterator], reportError) and the scrollY / requestAnimationFrame patches beside them. resetDom() from @studiometa/js-toolkit/test is the whole teardown. Also gone, all with zero consumers: lifecycle.ts (whose destroy() called the v3-only $destroy()), resizeWindow.ts (assigns to the read-only window.innerWidth), matchMedia.ts (clobbered a real global for every file importing the barrel), mockImageLoad.ts, mockVideoLoad.ts, faketimers.ts and components.ts.

Three failures the browser exposed, fixed rather than skipped

  • Disclosure's recovered transition failure became an unhandled global error. The component reports it on the diagnostic channel, whose default sink is reportError() — which in a real browser raises a global error the runner flags. Per the ruling that this is correct js-toolkit behaviour, the spec now captures the channel with captureDiagnostics(), which both asserts the code and suppresses the sink.
  • MapboxMap/dependencies.spec.ts and Motion/dependencies.spec.ts reloaded their module singleton with vi.resetModules(), which cannot clear the page's own module registry — verified with a standalone probe: the call returns the identical namespace object. Each test was therefore asserting against the previous test's sentinel. A distinct query string is a genuinely distinct module URL, and still goes through Vite's transform so vi.mock() still applies.
  • subpath-exports asserted '$isBase' in X, a v3 static. v4's brand is a private symbol, so the assertion is X.prototype instanceof Base.

LargeText

LargeText was the last import keeping the barrel from loading. withMountWhenInView(Base, { rootMargin: '50%' }) is the string in-view:50%: the registry owns the observer before any instance exists, and data-mount overrides the margin per element, which v3 could only do by declaring a second class. The three services v3 bound by the mere presence of resized(), scrolled() and ticked() are named as three mixins.

Two v4 signature changes are carried rather than worked around. damp() is per elapsed millisecond, not per frame, so the same 0.25 factor now means the same speed on every display — a behaviour change, and the one the framework intends. transform() is pure: it formats a string and leaves the write to the caller, so the style write is scheduled explicitly into the write phase of the frame the read ran in, replacing v3's return-a-closure-from-ticked().

LargeText.twig writes no ref, option or event that moved, so it is unchanged. The generic-API redesign V2-COMPONENTS-REVIEW.md asks for is not in this PR and remains outstanding, together with the same redesigns for Cursor and CircularMarquee.

The family gets its first spec: six cases covering the mount strategy, the measured loop distance, the reversal under a negative sensitivity, the wrap and the optional skew. It only means anything in a browser, because the loop distance is clientWidth.

Disclosure events

Both sides emitted a bare open/close, and v4's $emit() bubbles, so a listener on the group's element heard its children too — telling them apart meant reading event.target or the shape of the payload. Both take the family prefix Defer (defer-*) and Fetch (fetch-*) already set. The final names:

Before Now
Disclosure open, close, after-open, after-close disclosure-open, disclosure-close, disclosure-after-open, disclosure-after-close
DisclosureGroup open, close, change disclosure-group-open, disclosure-group-close, disclosure-group-change

disclosure-group-* is my resolution of the collision, not a user instruction — flagging it explicitly for review. So is renaming the whole event set of each side rather than only the two that collided: a half-namespaced family reads as an oversight, and after-open beside disclosure-open is one vocabulary spelled two ways. Disclosure.twig writes no event name and nothing else in the workspace listens for these, so the rename reaches the $emits types, the emit sites, the spec and packages/docs/reference/items/Disclosure/js-api.md.

Default exports restored

export default is back on every family's main component. The port had dropped it across the board — v1 had it on all thirty — and subpath-exports.spec.ts is right to assert it: @studiometa/ui/<Component> has always exposed the main component both ways. The decision named Disclosure and ScrollReveal; the spec names six, and a half-restored convention is worse than either extreme, so it is restored everywhere. Family members and sub-components keep only their named export, which is the distinction the spec draws.

Migrated families

Disclosure / DisclosureGroup. v3 answered "which group owns me?" with $closest('DisclosureGroup:mounted') and, because nothing announced a mount, backed it with a two-way document CustomEvent handshake and a document-wide MutationObserver. All of it is gone. The group holds a live $watchChildren(Disclosure) collection and claims the disclosures below it; a disclosure refuses a claim from a group further away than its current one, and falls back to the nearest still-mounted group when its own unmounts.

enable()/disable() wrote to $options in v3. REPORT.md counts that pair as the one genuine reconfiguration in the whole of @studiometa/ui, and names the replacement: write the presence-only attribute the option reads. optionDisabledChanged() replaces updated().

ScrollReveal. v3 built it on withMountWhenInView. v4's two successors each answer half of what it needs, and the repeat option chooses between exactly those two behaviours at runtime, from an attribute. So the component mounts normally and subscribes to useInView(), the core service the strategies are themselves built on. $terminate() has no v4 equivalent and needs none: the one-shot guard is a field.

Not in this PR

  • The Slider rewrite onto Indexable, and the Tabs rewrite.
  • The Cursor, LargeText and CircularMarquee generic-API redesigns, and the CircularMarquee migration.
  • The Twig extension (packages/twig-extension) and anything else on the PHP/Composer side.
  • The v1 → v2 migration guide. migration-guides/1.0-2.0/ exists but predates the port and now carries a warning saying so; the real guide is unwritten.
  • The ui-motion and ui-mapbox ports. Their peer dependency is bumped so the workspace resolves one major version, but their sources are still v3 code.

Documentation

packages/docs follows the port, in four commits: 195 files changed, 831 insertions, 5 354 deletions.

Removed

The Reference items of every dropped component, with their stories and catalog entries: Accordion, the whole Frame family, ImageGrid, Modal, Panel, Reinsurance, the ScrollAnimation family, animationScrollWithEase, withScrollAnimationDebug and withIndex11 item directories, 102 files.

viewTransition goes with them for a different reason: core absorbed it, so @studiometa/ui/scheduler no longer exists and the helper comes from @studiometa/js-toolkit. It was the only documented helper, so the sidebar stops offering a group with nothing in it while /reference/helpers/ keeps its URL and explains the absence. The Reference overview now reads Components 45 · Primitives 8 · Decorators 2 · Helpers 0 · Types 109 · All exports 239.

Transition's group option is removed too. It collected sibling instances from a global registry v4 does not keep, so the option, the Group example and its story are gone, and the FigureShopify reveal — which used group to run two transitions as one — drives the overlay it contains through $query() instead.

Renamed

LazyIncludeDefer with its defer-content / defer-error / defer-always events, AnchorScrollToScrollTo, PrefetchWhenOverPrefetchOnInteraction. Directories, prose, headings, stories, catalog ids and the sidebar all follow; each page keeps the old name as a search alias and carries a "Renamed in v2" note. The old URLs 404 — VitePress has no redirect mechanism here, and adding one was out of scope.

The catalog is the generated surface

reference/all-exports/, /types/, /components/, /primitives/, /decorators/, /helpers/ and the whole Reference sidebar are all rendered from .vitepress/reference/catalog.ts and public-contracts.ts, which are hand-maintained and validated against TypeScript's module graph by scripts/validate-reference.ts on every docs:build. That check was failing with 60 errors before this work. Both files are rebuilt against the real export surface — 78 stale symbols removed, 48 added — and it passes. The 16 unexported internal helpers needed nothing: they were never documented.

packages/ui/src/manifest.ts is the other generated file; npm run manifest:check is clean, so nothing was needed there.

Ported onto v4

Every live example is real component code, so the runtime move broke a set of them in ways no build catches:

  • createApp() does not exist in v4. Four stories and eight Reference pages used it. An application component is now an ordinary component with a data-component root, and $children.X[0] is $query('X')[0].
  • registerComponent() takes one argument. The alias/selector second parameter is gone, so registerComponent(ScrollTo, 'a[href^="#"]') registered nothing. Aliasing is a subclass declaring name.
  • withResponsiveOptions() is gone; every option is responsive. data-option-mode:xxs:xs:s="click" becomes the v4 cascade, data-option-mode="click" plus data-option-mode:m="hover".
  • withMountWhenInView is gone. Five components now name the mount strategy they declare, and InView / InViewOnce lose their intersectionObserver option with it — the margin is the data-mount suffix.
  • @component() registers its class on import, so the Draggable story's subclass was refused by the registry and its override never ran.
  • The autoloading guide was v3 end to end: data-loaddata-mount, four strategies → six, js-toolkit:eager / autoload() / registerManifests() / js-toolkit:errorregisterManifest() and the js-toolkit:diagnostic channel.

Verified in a browser

The docs' own live examples cannot run on this branch, for two reasons that are both in the port and neither in the documentation.

  1. @studiometa/playground@0.3.13's front-end is a js-toolkit v3 application. The workspace overrides pin js-toolkit to 4.0.0-alpha.1, so its shell throws (0, Z.createApp) is not a function and never renders a story. It also uses $children and destroyed(), so this is a port of that package, not a patch.
  2. The @studiometa/ui build emits untranspiled decorators. tsdown/rolldown at target: 'esnext' writes var Defer = @component({…}) class extends Base {} into dist/, which no shipping browser parses — 21 modules, and the same output reaches the playground bundle. Lowering the target to es2022 does not change it. The test suite never sees this because Vitest transforms the sources with esbuild, which does lower decorators.

So the stories were exercised through a temporary harness: each one's Twig rendered by the same /api/ endpoint the playground uses, its app.js bundled with esbuild against the real @studiometa/ui sources, served from the ddev docroot with console.warn/console.error, window.onerror, unhandledrejection and the js-toolkit:diagnostic channel all captured.

All 126 in-scope stories load with zero console errors, zero page errors and zero unhandled rejections. The only network failure anywhere is https://defer.invalid/ — which is the Defer error example doing its job.

Sixteen stories were then driven the way a reader would, in Chromium:

Story What happened
Disclosure basic Clicking a trigger moved aria-expanded and hidden across the group; single-open honoured
Defer basic hello world! injected, loading ref hidden
Defer error error ref revealed (display: block)
ScrollTo Click intercepted (defaultPrevented), window.scrollTo() called once, page at 1848px
Slider left / center / right Counter 1 / 102 / 10 on all three
Carousel horizontal / vertical scrollLeft 0 → 32, scrollTop 0 → 56
DataModel basic Typing propagated to the bound element
Fetch simple Page A → Page B swapped in
Action counter Double is 0.Double is -2.
Dialog modal <dialog>.open === true
Toaster basic 0 → 1 Toast
FigureShopify reveal Both halves of the reveal ran, image loaded from the Shopify CDN

Pages were also loaded directly: /reference/, /reference/all-exports/, /reference/helpers/, /guide/autoloading/, /reference/items/Defer/js-api, /reference/items/Prefetch/, /reference/items/Disclosure/examples. /reference/items/Modal/, /Frame/ and /LazyInclude/ correctly 404.

Two tooling defects found on the way

  • js twoslash blocks are not type-checked. A block importing a symbol @studiometa/ui does not export builds clean, with no error marker; every hover in the built site is empty. Twoslash itself is fine — run directly it reports the error. So docs:build says nothing about the code samples. All 21 remaining blocks were checked by hand through the twoslasher and pass.
  • npm run lint:prettier checks 3 of the 204 markdown files. packages/docs/**/*.md is passed to sh, where ** is not globstar, so it expands to files exactly two levels deep. Not changed here: quoting the glob would fail on dozens of pre-existing files. The 25 files this work touched were checked explicitly and pass.

Still open in the docs

  • The v1 → v2 migration guide is unwritten. migration-guides/1.0-2.0/ describes the ScrollAnimation refactor, whose result v2 removes entirely, and assumes js-toolkit v3 throughout. It now carries a danger callout saying both. A real guide needs: the six dropped families and what replaces each, the three renames with their event renames, the sixteen unexported helpers, and the js-toolkit v3 → v4 changes that reach a @studiometa/ui consumer.
  • @studiometa/ui-mapbox and @studiometa/ui-motion documentation is untouched and still describes v3 (importWhenVisible, withExtraConfig), which matches their unported sources. Both are excluded from the playground bundle in packages/playground/meta.config.js — without that, their dead js-toolkit subpaths fail the bundler and take docs:build with them. Put them back in the commit that ports them.
  • Tabs and CircularMarquee documentation is untouched, as their sources are.
  • The four Hero image examples ship no :script, so their Figure never registers and the image stays a placeholder. Pre-existing, unrelated to the port, left alone.

Verification

Run from the repository root, on this branch, at 9962d2f2.

npm run docs:build

Passes. validate-reference reports 55 Reference entries, 239 symbols and 5 concepts, and the site builds in ~27s. It failed at validate-reference with 60 errors before this round.

npm run lint

lint:static reports 28 warnings and 0 errors (29 before; one warning left with a deleted helper). lint:prettier passes.

lint:types fails, with 52 errors, down from 65, and none of them in a family on v4:

Package / area Errors Was Cause
packages/ui-mapbox/src 30 30 v3 package
packages/ui-motion/src 17 17 v3 package
packages/ui/src/LargeText 0 13 ported in this round
packages/ui/src/Tabs 3 3 v1 source, deferred rewrite
packages/ui/src/CircularMarquee 2 2 v1 source, out of scope

Does the @studiometa/ui barrel import?

Yes. index.spec.ts collects and passes, and the three other specs that waited on it are unblocked:

Spec Project Result
index.spec.ts chromium 1 passed (snapshot regenerated)
barrel-exports/barrel-exports.spec.ts node 3 passed (snapshot regenerated)
subpath-exports/subpath-exports.spec.ts chromium 10 passed
subpath-exports/resolution.spec.ts node 11 passed
subpath-exports/backward-compat.spec.ts chromium 3 passed
subpath-exports/backward-compat-resolution.spec.ts node 4 passed
autoload/manifest-freshness.spec.ts node 1 passed
autoload/manifest.spec.ts chromium still fails to collect — it imports @studiometa/ui-mapbox, whose v3 source reaches for @studiometa/js-toolkit/withExtraConfig. Not LargeText, and not fixable without the ui-mapbox port

Three of those files needed splitting, because they mixed two incompatible requirements. Importing @studiometa/ui registers every component, and registration needs a MutationObserver, so a spec that loads the barrel cannot run under Node at all — while the same files also called import.meta.resolve, node:fs and execFileSync. The repository half of each moved to its own *-resolution / *-freshness file in the Node project; the identity half stayed in the browser. No assertion was dropped.

npm run test

 Test Files  29 failed | 53 passed (82)
      Tests  3 failed | 571 passed | 1 skipped (575)

Three consecutive full runs produced that line, character for character. The happy-dom baseline on the same tree and the same alpha was 169 failed | 370 passed (539) across a 154–169 band. The one skipped test is a pre-existing it.skip in Tabs/Tabs.spec.ts; I did not delete or skip a single test.

The 3 remaining test failures are all Tabs/Tabs.spec.ts, and they are real: v1 Tabs emits $emit('enable', item), and v4 delivers the payload differently, so the listener receives undefined. The runner is not hiding them any more — happy-dom never collected this file. They belong to the deferred Tabs rewrite and are left failing rather than adjusted.

The 29 failing test files are all collection failures in unported packages: 20 MapboxMap/* and 7 Motion/* (v3 sources importing ./withExtraConfig, ./utils/nextTick, ./utils/addClass, ./utils/removeClass), autoload/manifest.spec.ts (same cause, via ui-mapbox), and Tabs/Tabs.spec.ts (counted as failed because of its 3 red tests). None is a @studiometa/ui v4 family.

Export-surface snapshots

Both were regenerated. Every removal is a documented v2 drop or rename — the Accordion, Frame, Modal and ScrollAnimation families with their *Props types, AbstractSliderChild, withIndex, viewTransition (core absorbed it), and the three renames LazyIncludeDefer, AnchorScrollToScrollTo, PrefetchWhenOverPrefetchOnInteraction.

The additions split three ways:

  1. New surface from the port, expected: AbstractFigure, AbstractFigureDynamic, AbstractTrack, ActionEvent, TrackEvent, CarouselContext, SliderContext, DataRegistry, DataRegistryContext, plus the renamed classes and a long tail of new *Props/state types.

  2. Named constants, following the framework-wide "frozen const object over magic strings" convention: EFFECT_ARGUMENTS, FETCH_EVENTS, HEADER_NAMES, INDEXABLE_BOUNDARIES, INDEXABLE_INSTRUCTIONS, MODIFIERS, SECTIONS_PARAMETER, TRACK_PSEUDO_EVENTS. MODIFIERS and HEADER_NAMES are very generic names for a public barrel. Kept deliberately: they exist so a no-build-step consumer writes INDEXABLE_BOUNDARIES.loop rather than a bare string, which is the whole point of the convention.

  3. Sixteen internal helpers — resolved: removed from the public surface. compileExpression, getCallback, getClosestIndex, getEffect, isCheckbox, isInput, isSelect, parseEventDefinition, readControlValue, resolveDataRegistry, resolveDetailPlaceholders, resolvePropertyName, serializeControlValue, setProperty, valuesEqual and writeControlValue no longer leave the package, together with the two types that described their returns (CompiledExpression, ParsedEventDefinition).

    They reached the surface because the families export them by name for each other and the root barrel re-exported those family barrels whole. The test applied was documented plus a dedicated subpath, which separates the set cleanly: of the eighteen candidates only withDeprecation and withTransition are documented, and they are the only two with a subpath entry of their own. Both stay.

    None of the sixteen was reachable by subpath, so no exports entry changed, and every call site imports relatively and is untouched — the type-error count holds at 52 and the suite at 3 failed / 571 passed. Both snapshots are regenerated.

Also worth noting: several *Props types the v1 barrel exported for components that survive (ClickOutsideProps, CarouselItemProps, CarouselWrapperProps, CarouselDragProps, SliderItemProps, TargetProps) no longer exist anywhere in the sources — the port simply never declared them.

Judgement calls worth challenging

  1. disclosure-group-* is my naming, not a decision I was handed. So is prefixing the whole event set of each side rather than only the two names that collided.
  2. export default was restored on all 26 remaining main components, where the decision named two. The spec names six, and stopping at six would leave the convention half-applied.
  3. damp()'s new time base is a behaviour change in LargeText, not a translation. The v3 marquee ran faster on a 120 Hz display; it no longer does. Same for CircularMarquee when it is ported.
  4. LargeText keeps its public mutable fields (x, deltaY, transform, width) exactly as v1 had them, including the transform field shadowing the imported transform() helper. That is the port being a port; the redesign is where it should change.
  5. optimizeDeps.noDiscovery is a workaround for unported packages, not a permanent setting. When ui-motion and ui-mapbox are ported it should come back off, and the up-front scan is worth having.
  6. The node project deliberately has no typescript resolve condition. Nothing in it imports a package under test, and two of its specs assert where the published exports map points — which the source condition would answer with src/ and hide.
  7. Disclosure and DisclosureGroup lost their props type parameter, deleting roughly fifteen as unknown as Disclosure casts and costing a subclass the ability to add its own props.
  8. An unmounted DisclosureGroup whose element is still in the DOM hands its children back to the outer group. v1's behaviour, preserved by the group's mounted() cleanup rather than by context resolution.
  9. Disclosure no longer reflects its open state into data-option-open. aria-expanded on the trigger is the DOM statement of open state. Markup styling on [data-option-open] after the first render would break. Confirmed by decision.
  10. Excluding ui-mapbox and ui-motion from the playground bundle is what makes docs:build pass at all. It is a workaround for two unported packages, and their stories cannot run until they are ported. Challenge it if you would rather the build stay red.
  11. The Transition group option and its example were deleted rather than reimplemented. group has no v4 equivalent and the review does not ask for one, but a reader who used it now has no documented replacement beyond the $query() pattern the FigureShopify reveal shows.
  12. The Defer error example points at https://defer.invalid/. It has to: fetch() resolves for a 404, and Defer does not check response.ok, so the previous /404 example injected the error page's markup instead of showing the error ref. The example is honest now, but the component arguably should treat a non-OK response as an error — that is a port decision, not a docs one. Defer also leaves the loading ref visible after a failure; only defer-content hides it.
  13. Twenty-one of the ported components self-register through @component() and the rest do not. The docs describe it as a fact rather than a rule, because it is not one yet: Disclosure, Slider, Figure, Dialog and the Data* family still need an explicit registerComponent(), and nothing on the page tells a reader which is which.
  14. ScrollReveal.repeat keeps the page-direction skip through useWindowScroll(), rather than reading which edge the element crossed off the IntersectionObserverEntry useInView() already delivers. The alternative would be local and behave better in a nested scroll root, but it is a behaviour change rather than a port.

Contradictions found

  1. The port does not cover the whole surface the review keeps. V2-COMPONENTS-REVIEW.md marks Disclosure, DisclosureGroup, ScrollReveal, CircularMarquee and LargeText as kept, and REPORT.md never ported any of them. Four of the five are migrated here; CircularMarquee is not.
  2. Slider is a full port in REPORT.md and a rewrite in the review. Resolved by decision: the port lands now, the rewrite is future work.
  3. withIndex is kept by the review and does not exist in the port. I followed the port and dropped the decorator: keeping a v3 decorator that wraps a class which no longer needs wrapping would be a compatibility layer.
  4. REPORT.md's "proved in Vitest browser mode" is now literally true here too. The previous round's note that SliderDrag "cannot pass in this repository's runner" is obsolete — all 29 Slider tests pass, as do all 28 Disclosure tests, all 9 ScrollReveal tests and all 12 InView tests.
  5. V2-COMPONENTS-REVIEW.md asks for a generic-API redesign of LargeText. This PR ports it as it stands. The redesign is outstanding.
  6. migration-guides/1.0-2.0/ documents a v2 that no longer exists. It tells the reader to migrate ScrollAnimation to ScrollAnimationTimeline and ScrollAnimationTarget, and v2 removes the whole family in favour of @studiometa/ui-motion. Flagged on the page, not rewritten.

What remains for v2

  • Run the ported specs in a real browser (Vitest browser mode)
  • Port LargeText onto v4 — the barrel imports again
  • Regenerate the index.spec.ts and barrel-exports.spec.ts export-surface snapshots
  • Delete or rewrite packages/tests/__utils__/lifecycle.ts
  • Replace the SmoothToRecord interface copied into Cursor.ts
  • Port CircularMarquee onto v4
  • Rewrite Slider onto the Indexable foundation, keeping the continuous-drag physics
  • Rewrite Tabs — its 3 red spec assertions are the specification
  • Redesign Cursor, LargeText and CircularMarquee for a generic API
  • Port @studiometa/ui-motion onto v4, then turn optimizeDeps.noDiscovery back off
  • Port @studiometa/ui-mapbox onto v4 — it is what still blocks autoload/manifest.spec.ts
  • Decide on the 16 Data/Action helpers and the 8 named constants now pinned in the barrel snapshot
  • Update packages/docs for the v2 surface: drop the Frame and ScrollAnimation stories, rename the LazyInclude, AnchorScrollTo and PrefetchWhenOver pages
  • Write the v1 → v2 migration guide
  • Port @studiometa/playground onto v4, or pin it, so the docs' live examples run again
  • Make the @studiometa/ui build lower its decorators — dist/ is unparseable in a browser today
  • Fix the twoslash renderer: hovers are empty and errors never reach the page
  • Fix lint:prettier's glob, which checks 3 of 204 markdown files
  • Restore @studiometa/ui-mapbox and @studiometa/ui-motion in packages/playground/meta.config.js when they are ported
  • Update packages/eslint-plugin-ui, which still names dropped components
  • Update the Twig extension and the PHP side
  • Decide whether withDeprecation earns its place with no consumer left
  • Decide on @studiometa/js-toolkit/utils barrel imports vs. per-symbol subpaths in the ported sources
  • Remove alien-signals from the @studiometa/ui dependencies — Data* no longer uses it

🤖 Generated with Claude Code

https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R

Decorators removed, and the last two packages ported

Two changes landed after the documentation pass, on merged branches.

The published package was broken, and decorators were the cause. @studiometa/ui's build emitted untranspiled decorator syntax into dist/var Defer = @component({…}) class extends Base {} — so 21 modules threw SyntaxError: Invalid or unexpected token on import, in Node 24 as well as in browsers. The test suite could not see it: Vitest transforms sources with esbuild, which lowers decorators, so the shipped artifact was never exercised.

The fix is not a build flag. The ui components no longer use decorators at all: @component() ×21 → static config, @on() ×8 → the on<Event> convention or an explicit $on(), plus @provide(), @children() ×2 and @write() ×2. @rollup/plugin-swc and @swc/core are gone from packages/tests.

Two consequences worth review:

  • Registration is now the consumer's call, everywhere. @component() called registerComponent(this) internally, so those 21 components self-registered merely by being imported. None of the ~60 never-decorated components did, every spec and story registers explicitly, and registerManifest() registers the resolved class itself — so the autoloader never depended on it. "sideEffects": ["./dist/autoload.js"] already declared that no other module has side effects, which means that self-registration was droppable by any tree-shaker and never reliable.
  • Fetch.navigate, Fetch.send, Fetch.restore and CarouselWrapper.reportScroll are renamed to their convention names. A downstream subclass overriding one becomes dead code with no error.

ui-mapbox and ui-motion are ported. The four dead imports resolved as: withExtraConfig deleted (v4 merges config along the prototype chain), nextTick deleted in favour of $watchChildren() (StoreLocator was polling for a child), addClass/removeClassclassList. debounce was never gone and is untouched.

The playground bundle exclusion is reverted, and autoload/manifest.spec.ts collects and passes — it had been asserting a v3 manifest shape (token, packageName, strategy) that no v4 entry has, hidden behind its own collection failure.

Verified on the merged branch

Before this round Now
dist modules that parse 96 of 117 148 of 148
Type errors 52 5 (Tabs 3, CircularMarquee 2)
Test files 29 failed / 53 passed 1 failed / 81 passed (82)
Tests 3 failed / 571 passed (575) 3 failed / 797 passed / 1 skipped (801)
docs:build passes passes (55 entries, 239 symbols)
oxlint 28 warnings, 0 errors 27 warnings, 0 errors

226 tests were revived and all pass. The only failing file is Tabs, which waits on its rewrite.

Still open

  • No story on the docs site executes. @studiometa/playground's own runtime is a js-toolkit v3 application and throws createApp is not a function before rendering anything. Site-wide and pre-existing — it hits pages that were never excluded — so it is not caused by either change here, but the playground cannot be trusted until that package is ported.
  • Fetch.ts:81 constructs new DOMParser() at module scope, which makes Fetch, both Shopify subclasses and the root index.js un-importable under Node or SSR.
  • MapboxCluster keeps its v3 register()/unregister() handshake rather than watching its items, because both are documented public API.
  • MotionSequence and MotionScrollTimeline read children inside mounted() and await whenDOMSettled(), which does not wait for visibility strategies.

titouanmathis and others added 4 commits August 26, 2026 22:06
Record the full inventory of the v2 public surface with the per-component
keep, drop, rename and rewrite decisions. This document is the plan of
record for the ui v2 major version and outranks the js-toolkit migration
port wherever the two disagree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
Move every package that depends on @studiometa/js-toolkit from ^3.9.0 to
^4.0.0-alpha.0, and lift the root override with them. v2 of the ui
packages is built on v4, so the whole workspace has to resolve one major
version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
Replace the v1 TypeScript sources of 23 component families with the port
written and proved against js-toolkit v4 in the js-toolkit repository, and
drop the families the v2 review drops.

Ported: Action, AnchorNav, Carousel, ClickOutside, Cursor, Data, Dialog,
Draggable, Fetch, Figure, FigureVideo, Hoverable, InView, Indexable, Menu,
Prefetch, Sentinel, Sticky, Timer, Toaster, Track, Transition and
ViewTransition, plus the withTransition mixin the port brought back.

Renamed, per the review: LazyInclude to Defer (directory, class, config
name and its three events, now defer-content, defer-error and
defer-always), AnchorScrollTo to ScrollTo, PrefetchWhenOver to
PrefetchOnInteraction.

Dropped: Accordion (superseded by Disclosure and DisclosureGroup), Frame
(superseded by Fetch), Modal and Panel (superseded by Dialog),
ScrollAnimation with animationScrollWithEase and withScrollAnimationDebug
(superseded by ui-motion), the ImageGrid and Reinsurance templates, the
StyledModal and StyledPanel templates, and the withIndex decorator, whose
whole body is now the Indexable class.

Convert every ported `#private` field and method to the `__` prefix the ui
conventions require, with an `@private` or `@protected` docblock on methods
and getters, and put back the `__abortController`, `__appendSections` and
`__snap` names the port had turned into `protected` keywords.

Rebuild the catalog, the generated manifest and the package exports map
around the v2 surface. A v4 manifest entry is `{ mountStrategy, load }`;
the v3 token, packageName, subpath, exportName, group, children, styles and
integrations fields had no runtime consumer and stay in the authoring
catalog.

Keep the Twig templates of every family the port touches. None of them
needed a rename: their refs (img, video, inner), their options (lazy,
transform, domain, path) and their nested Sentinel all survive the port
unchanged.

Slider is deliberately left on its v1 source: the review calls for a
rewrite onto the Indexable foundation, which is separate work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
The port keeps its specs beside its sources; in ui they live in
packages/tests, so move all 37 of them there and resolve their imports
through the `#private/*` alias. Delete the v1 specs of the families the
port replaces, and the spec directories of the families v2 drops:
Accordion, Frame, Modal and ScrollAnimation. The v1 Indexable spec goes
with them, because withIndex is gone and the port ships no replacement.

Two harness changes the port forces:

Stage-3 decorators. The ported components declare `@component` and `@on`,
which Oxc, Vite's TypeScript transformer, does not lower, so every file
holding one failed to parse. Compile them with SWC first, behind a filter
that keeps the transform off files with no decorator in them. This is the
same plugin js-toolkit uses for its own suite.

Per-test cleanup. The setup file destroyed every mounted instance through
v3's page-wide `getInstances()`, which v4 removed: instances live on their
element and nothing collects them. Empty the document with `resetDom()`
from `@studiometa/js-toolkit/test` instead, which lets the shared mutation
observer dispose every controller it built.

Point the export-surface specs at families v2 keeps, and read the merged
transition config through `$options` rather than js-toolkit's unpublished
`resolveConfig()`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown

Code Review

Risk: Low — no blocking issues; safe to merge aside from nits.

This MR ports the UI component families and the ui-motion/ui-mapbox packages to js-toolkit v4, updates the browser-based test harness, and migrates the playground and documentation to the new runtime and export surface. I reviewed the added diffs for packages/ui/src/Disclosure/Disclosure.ts, packages/ui/src/Disclosure/DisclosureGroup.ts, packages/ui/src/LargeText/LargeText.ts, packages/ui/src/Defer/Defer.ts, packages/ui/src/Slider/Slider.ts, packages/ui/src/ScrollReveal/ScrollReveal.ts, packages/ui/src/Transition/Transition.ts, packages/ui-motion/src/MotionSequence.ts, and packages/ui-mapbox/src/StoreLocator.ts.

Notes:

  • The review skipped all files listed by the request under .code-review-skipped/, including the remaining component sources, package/config files, tests, generated files, documentation, and scripts; those files were not opened or reviewed.

Still open from earlier reviews (3 findings):

  • packages/ui/src/Menu/MenuList.ts:113issue: Iterate the child list collection items
  • packages/ui/src/manifest.ts:43issue (blocking): Remove the unported CircularMarquee from the v4 manifest
  • packages/ui/src/manifest.ts:228issue: Remove the unported Tabs from the v4 manifest

Review usage: 485,672 in (398,258 cached) / 2,092 out tokens — $0.0808 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit cabaea3.

Previous review runs

Previous run archived 2026-08-28T12:19:34Z

Code Review

Risk: High — blocking issues remain — resolve them before merge.

This change ports most component families and related packages to js-toolkit v4, updates manifests and exports, migrates documentation, and replaces the test harness with Vitest browser mode. The declared intent is not fully met because CircularMarquee and Tabs remain on v3 APIs while their manifest entries are included in the v4 runtime surface.

2 issues found:

  • issue (blocking)packages/ui/src/manifest.ts:43 — Remove the unported CircularMarquee from the v4 manifest
  • issuepackages/ui/src/manifest.ts:228 — Remove the unported Tabs from the v4 manifest

Notes:

  • Reviewed source diffs for ScrollReveal, Disclosure, Slider, Defer, LargeText, Transition, catalog, manifest, and index, plus the current CircularMarquee, Tabs, and autoload sources and repository-wide v3-subpath references.
  • Did not open the files listed in the supplied skipped_files list; those diffs remain unreviewed, including most component, test, documentation, package-configuration, generated, and script files.

Still open from earlier reviews (1 finding):

  • packages/ui/src/Menu/MenuList.ts:113issue: Iterate the child list collection items

Review usage: 943,814 in (844,071 cached) / 3,959 out tokens — $0.1164 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 5817b7c.

Previous run archived 2026-08-28T12:11:45Z

Code Review

Risk: Low — No concrete defects were identified in the reviewed source files; the change is safe to merge subject to reviewing the remaining staged diffs.

The MR ports the component families and the ui-motion/ui-mapbox packages to js-toolkit v4, migrates Disclosure, LargeText, ScrollReveal and Slider, updates event and registration APIs, and moves browser tests to Vitest with Playwright Chromium. I reviewed packages/ui/src/Disclosure/Disclosure.ts, packages/ui/src/Disclosure/DisclosureGroup.ts, packages/ui/src/Slider/Slider.ts, packages/ui/src/ScrollReveal/ScrollReveal.ts, packages/ui/src/Defer/Defer.ts, packages/ui/src/Fetch/Fetch.ts, packages/ui/src/Figure/AbstractFigure.ts, packages/ui/src/Menu/Menu.ts, packages/ui/src/decorators/withTransition.ts, packages/ui-motion/src/Motion.ts, packages/ui-motion/src/MotionSequence.ts, and packages/ui-mapbox/src/StoreLocator.ts. I did not open the other changed files, including the files listed under .code-review-skipped/, so those portions remain unreviewed.

Still open from earlier reviews (1 finding):

  • packages/ui/src/Menu/MenuList.ts:113issue: Iterate the child list collection items

Review usage: 474,644 in (423,062 cached) / 1,630 out tokens — $0.0583 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit bd06b0e.

Previous run archived 2026-08-28T10:28:38Z

Code Review

Risk: Low — No demonstrable defects were found in the reviewed source diffs; the change is safe to merge from the inspected files.

This MR ports component families to js-toolkit v4, migrates Disclosure, LargeText, ScrollReveal, and Slider, updates exports and documentation, and switches the test suite to Vitest browser mode. I reviewed packages/ui/src/Disclosure/Disclosure.ts, packages/ui/src/Disclosure/DisclosureGroup.ts, packages/ui/src/LargeText/LargeText.ts, packages/ui/src/ScrollReveal/ScrollReveal.ts, and packages/ui/src/Slider/Slider.ts diffs, plus targeted searches across the staged source diffs. I did not open the files listed under <skipped_files>, including the remaining component source diffs, tests, configuration, generated files, package manifests, documentation, and decision record.

Still open from earlier reviews (1 finding):

  • packages/ui/src/Menu/MenuList.ts:113issue: Iterate the child list collection items

Review usage: 408,405 in (361,956 cached) / 1,435 out tokens — $0.0514 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 9962d2f.

Previous run archived 2026-08-27T21:21:07Z

Code Review

Risk: Low — No concrete defects were identified in the reviewed source changes; the reviewed subset is safe to merge aside from the unreviewed files listed below.

The MR ports component families to js-toolkit v4, adds the Slider, Disclosure, ScrollReveal, and LargeText migrations, updates the manifest and exports, and moves tests to browser mode. The reviewed implementations consistently adopt v4 lifecycle, context, signal, service, and event APIs, with the intentional Slider and LargeText redesign deferrals documented in the change context.

Notes:

  • Reviewed: packages/ui/src/Disclosure/Disclosure.ts, packages/ui/src/Disclosure/DisclosureGroup.ts, packages/ui/src/ScrollReveal/ScrollReveal.ts, packages/ui/src/LargeText/LargeText.ts, packages/ui/src/Slider/Slider.ts, packages/ui/src/Defer/Defer.ts, packages/ui/src/catalog.ts, and packages/ui/src/index.ts.
  • Not opened: all other files in the supplied skipped-files list, including the remaining component sources, tests, package/config diffs, generated files, and decision record.

Still open from earlier reviews (1 finding):

  • packages/ui/src/Menu/MenuList.ts:113issue: Iterate the child list collection items

Review usage: 192,755 in (156,115 cached) / 1,519 out tokens — $0.0353 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 118192c.

Previous run archived 2026-08-27T15:39:45Z

Code Review

Risk: Low — No demonstrable blocking or wrong-behaviour defects were found in the reviewed changes; the migration is safe to merge aside from the unreviewed files noted below.

This MR ports the component families to js-toolkit v4, adds the Disclosure, ScrollReveal, Slider, and LargeText migrations, updates the generated manifest and exports, and moves the test runner to browser mode. The declared Slider rewrite onto Indexable remains intentionally deferred, consistent with the stated plan.

Reviewed files: packages/ui/src/Disclosure/Disclosure.ts, packages/ui/src/Disclosure/DisclosureGroup.ts, packages/ui/src/ScrollReveal/ScrollReveal.ts, packages/ui/src/Slider/Slider.ts, packages/ui/src/LargeText/LargeText.ts, packages/ui/src/manifest.ts, and packages/ui/src/index.ts. I did not open the other staged diffs listed in the prompt, including the remaining component sources, package/config files, tests, generated scripts, documentation, and skipped-file diffs.

Still open from earlier reviews (1 finding):

  • packages/ui/src/Menu/MenuList.ts:113issue: Iterate the child list collection items

Review usage: 168,783 in (132,631 cached) / 1,095 out tokens — $0.0325 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 02e6436.

Previous run archived 2026-08-27T12:59:58Z

Code Review

Risk: Low — No demonstrable blocking defects were found in the source files reviewed; the reviewed changes are safe to merge aside from the unreviewed files listed below.

This change ports the component families to js-toolkit v4, adds the Slider, Disclosure, and ScrollReveal migrations, and updates lifecycle, event, context, and collection handling for the new API. I reviewed packages/ui/src/Disclosure/Disclosure.ts, packages/ui/src/Disclosure/DisclosureGroup.ts, packages/ui/src/ScrollReveal/ScrollReveal.ts, packages/ui/src/Slider/Slider.ts, and searched the staged UI source diffs for related API usage. I did not open any other staged diff files, including every path listed under the supplied skipped_files list; those files remain unreviewed.

Still open from earlier reviews (1 finding):

  • packages/ui/src/Menu/MenuList.ts:113issue: Iterate the child list collection items

Review usage: 254,655 in (217,445 cached) / 1,791 out tokens — $0.0395 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit f75793e.

Previous run archived 2026-08-26T23:12:22Z

Code Review

Risk: Medium — issues that should be addressed before merge.

Ports the retained component families to js-toolkit v4, updates the catalog and manifest surface, renames Defer, ScrollTo and PrefetchOnInteraction, and removes the families listed as dropped by the decision record. I reviewed packages/ui/src/manifest.ts, packages/ui/src/catalog.ts, packages/ui/src/index.ts, packages/ui/src/decorators/withTransition.ts, packages/ui/src/Transition/Transition.ts, packages/ui/src/ViewTransition/ViewTransition.ts, packages/ui/src/Dialog/Dialog.ts, packages/ui/src/Defer/Defer.ts, packages/ui/src/Prefetch/AbstractPrefetch.ts, packages/ui/src/Prefetch/PrefetchOnInteraction.ts, packages/ui/src/AnchorNav/AnchorNavLink.ts, packages/ui/src/Menu/MenuList.ts, packages/ui/src/Action/ActionEvent.ts, packages/ui/src/ScrollTo/ScrollTo.ts, and packages/ui/src/Figure/AbstractFigure.ts. I did not open the remaining staged diffs, including the other component sources, tests, package/config files, generated scripts, templates, and decision-record diff.

1 issue found:

  • issuepackages/ui/src/Menu/MenuList.ts:113 — Iterate the child list collection items

Review usage: 217,952 in (157,188 cached) / 2,481 out tokens — $0.0533 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 1d2958a.

for (const menuList of this.$children.MenuList) {
menuList.close();
for (const list of this.__lists) {
list.close();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Iterate the child list collection items

this.__lists is a ChildrenCollection, whose instances are accessed through .items elsewhere in this file (transitionChildren.items and viewTransitionChildren.items). Iterating the collection object directly causes nested-list closing to fail at runtime instead of closing each child. Iterate over this.__lists.items.

Confidence: high.


Reviewed by @weareikko/code-review v0.9.5 for commit 1d2958a.

@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.09%. Comparing base (c939977) to head (5817b7c).

Additional details and impacted files
@@             Coverage Diff              @@
##                2.x     #642      +/-   ##
============================================
- Coverage     87.80%   86.09%   -1.71%     
============================================
  Files           153        7     -146     
  Lines          5443      223    -5220     
  Branches       1037       88     -949     
============================================
- Hits           4779      192    -4587     
+ Misses          580       24     -556     
+ Partials         84        7      -77     
Flag Coverage Δ
unittests 86.09% <ø> (-1.71%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.
see 146 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

titouanmathis and others added 23 commits August 27, 2026 00:45
Land the seven-class port proved in `studiometa/js-toolkit`
(`packages/js-toolkit/migration/Slider/`), replacing the v1 source.

`AbstractSliderChild` is deleted: its whole job was finding the parent
Slider and subscribing to its store, retried from `mounted()`, `resized()`
and `updated()` because none was reliable alone. One `SliderContext`
carrying `{ state, goTo, goNext, goPrev }` replaces all of it, so no
control imports the `Slider` class. `config.emits` becomes `$emits` in the
props type, `$children` becomes `$watchChildren`, and `goTo()` clamps
instead of throwing now the slide collection is live.

The rewrite onto `Indexable` that `V2-COMPONENTS-REVIEW.md` calls for is
deliberately deferred, not overlooked: this lands the working port now and
leaves the rewrite as separate work. The ported `Slider` does not touch
`Indexable` at all.

The `#`-private fields the port used become `__`-prefixed members with
`@private` docblocks, per this repository's conventions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
No port existed for this family, so this is the migration itself.

The group/child handshake is gone. v3 answered "which group owns me?" with
`$closest('DisclosureGroup:mounted')` and, because nothing announced a
mount, backed it with a two-way document `CustomEvent` handshake and a
document-wide `MutationObserver` re-running the lookup after any DOM
change. `DisclosureGroup` now holds a live `$watchChildren()` collection
and claims the disclosures below it; a disclosure refuses a claim from a
group further away than its current one, and falls back to the nearest
still-mounted group when its own unmounts. Nesting, mounting in either
order, DOM moves and group teardown all follow from that, with no listener
on `document` and no observer.

`enable()`/`disable()` wrote to `$options` in v3 — the one genuine
reconfiguration in the whole of `@studiometa/ui`, per the port's report.
They now write the presence-only attribute the option reads, and
`optionDisabledChanged()` replaces `updated()` for markup-driven changes.
The other three `$options` writes were state in the wrong place and are
the `isOpen` field.

`config.emits` becomes `$emits`: the disclosure's four events carry no
payload, since v4 hands the emitter over as the event target, and the
group's three carry the item and index v3 passed as extra arguments.
`destroyed()` becomes the cleanup `mounted()` returns, and `nextTick()`
becomes the scheduler's background lane.

The spec is rewritten against the v4 lifecycle: `$destroy()` and
`$update()` no longer exist, so it registers components and drives real
DOM instead of constructing instances by hand.

`reportError()` is polyfilled in the happy-dom setup. It is a platform
global neither Node nor happy-dom provides, js-toolkit's diagnostic
channel calls it as its default error sink, and `$error()` therefore threw
a `ReferenceError` that poisoned the rest of the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
No port existed for this family either.

v3 built it on `withMountWhenInView`, and v4 has no decorator to replace.
Its two successors each answer half of what this needs: `mountStrategy:
'visible'` mounts once and never unmounts, `'in-view'` mounts and unmounts
on every crossing — and the `repeat` option chooses between exactly those
two behaviours at runtime, from an attribute, which a static declaration
cannot express. So the component mounts normally and subscribes to
`useInView()`, the core service the strategies are themselves built on.
That also keeps `intersectionObserver` meaningful as the observer's init
rather than a `rootMargin` smuggled into a `data-mount` suffix.

`$terminate()` has no v4 equivalent and needs none: the one-shot guard is a
field, which survives the unmount/mount pair a DOM move now is. The `static
scrollDirectionY` fed by a page-wide `useScroll()` callback registered
under a fixed key and never removed is a per-instance subscription: a v4
service is shared and lazy, and this instance now lives as long as its
element.

`ScrollReveal extends Transition` becomes `withTransition(Base)`, so it no
longer registers under a parent's identity, and the family gets its first
spec.

Two more happy-dom gaps are papered over in the test setup, both of which
break `useScroll()`/`useWindowScroll()` for every family, not just this
one: `window instanceof Window` is false under the global registrator, so
the service cannot tell a window target from an element one, and
`HTMLCollection` has no `Symbol.iterator`, which WebIDL gives it in every
browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
Bump `@studiometa/js-toolkit` to `4.0.0-alpha.1` in every package that
declares it and in the workspace override, then refresh the lockfile.
The installed version is checked rather than inferred from the range.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
`4.0.0-alpha.1` exports `SmoothToRecord` from
`@studiometa/js-toolkit/utils`, so the interface copied into `Cursor.ts`
against `4.0.0-alpha.0` is deleted along with the comment explaining it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
Replace happy-dom with Vitest browser mode on Playwright Chromium, the
runner `@studiometa/js-toolkit` proves its own suite with. The components
ask the platform for `IntersectionObserver`, layout geometry, computed
styles and `document.location`, and js-toolkit is entitled to the browser
globals it uses — `reportError()` among them. A DOM emulation answers none
of that, and 167 of the 169 failures were the emulation rather than the
port.

The suite is two projects now. `chromium` runs everything that touches the
DOM. `node` runs the four export-surface specs that read the repository
instead — the TypeScript compiler API, `node:fs`, `node:child_process` —
and cannot run in a page.

The root moves from `packages/` to this package. That is what hung the
previous attempt: `packages/` is harmless as a Node root, but as a Vite
server root it makes the dev server crawl the whole monorepo and the run
never reaches the first test. Dependency discovery is off for the same
class of reason — `ui-motion`, `ui-mapbox` and `Tabs` still import subpaths
v4 removed, and the up-front scan turned one dead subpath into a failure
of the whole run instead of the one spec that owns it.

The three shims that papered over happy-dom breaking platform invariants
(`window instanceof Window`, `HTMLCollection[Symbol.iterator]`,
`reportError`) are gone with the dependency, and so are the `scrollY` and
`requestAnimationFrame` patches beside them. `resetDom()` from
`@studiometa/js-toolkit/test` is the whole teardown.

The helpers a real browser makes pointless go too: `lifecycle.ts` (no
consumer left, and its `destroy()` called the v3-only `$destroy()`),
`resizeWindow.ts` (assigns to the read-only `window.innerWidth`),
`matchMedia.ts` (clobbered a real global for every file importing the
barrel), `mockImageLoad.ts`, `mockVideoLoad.ts`, `faketimers.ts` and
`components.ts` — none had a consumer.

Three specs the new runner exposed are fixed rather than skipped:

- `Disclosure` reports its recovered transition failure on the diagnostic
  channel, whose default sink is `reportError()`. In a browser that becomes
  a global error the runner flags. The spec captures the channel with
  `captureDiagnostics()`, which both asserts the code and suppresses the
  sink.
- The two `dependencies.spec.ts` files reloaded their module singleton with
  `vi.resetModules()`, which cannot clear the page's own module registry.
  A distinct query string is a genuinely distinct module, and still goes
  through Vite's transform so `vi.mock()` still applies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
`Disclosure` and `DisclosureGroup` both emitted a bare `open` and `close`,
and v4's `$emit()` bubbles, so a listener on the group's element heard its
children too — telling them apart meant reading `event.target` or the shape
of the payload. Both sides take the family prefix `Defer` (`defer-*`) and
`Fetch` (`fetch-*`) already set:

- `Disclosure` emits `disclosure-open`, `disclosure-close`,
  `disclosure-after-open` and `disclosure-after-close`.
- `DisclosureGroup` emits `disclosure-group-open`,
  `disclosure-group-close` and `disclosure-group-change`.

The whole event set of each side is renamed, not only the two that
collided: a half-namespaced family reads as an oversight, and `after-open`
beside `disclosure-open` is the same event vocabulary spelled two ways.
`Disclosure.twig` writes no event name, so it needs no edit; nothing else
in the workspace listens for these.

`export default` comes back on every family's main component. The port had
dropped it across the board, and `subpath-exports.spec.ts` is right to
assert it: `@studiometa/ui/<Component>` has always exposed the main
component both ways. Family members and sub-components keep only their
named export, which is the distinction that spec draws.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
`LargeText` was the last import keeping the `@studiometa/ui` barrel from
loading: it reached for `./withMountWhenInView`, which v4 replaced with the
`data-mount` strategies. `withMountWhenInView(Base, { rootMargin: '50%' })`
is the string `in-view:50%` — the registry owns the observer before any
instance exists, and `data-mount` overrides the margin per element, which v3
could only do by declaring a second class.

The three services v3 bound by the mere presence of `resized()`,
`scrolled()` and `ticked()` are named as three mixins. Two v4 signature
changes are carried rather than worked around: `damp()` takes the elapsed
milliseconds the frame service already delivers, so the same `0.25` factor
now means the same speed on every display; and `transform()` is pure, so the
style write is scheduled explicitly instead of happening inside it. The
template writes no ref, option or event that moved, so it is unchanged. The
generic-API redesign the review asks for is **not** in this commit.

The family gets its first spec: six cases covering the mount strategy, the
measured loop distance, the reversal, the wrap and the optional skew. It
only means anything in a browser, because the loop distance is
`clientWidth`.

With the barrel loading, the four specs that waited on it are unblocked, and
three of them turned out to need splitting. Importing `@studiometa/ui`
registers every component, and registration needs a `MutationObserver`, so a
spec that loads the barrel cannot run under Node at all — while the same
files also asserted `import.meta.resolve`, `node:fs` and a generator run.
The repository half of each moves to its own `*-resolution` /
`*-freshness` file in the Node project; the identity half stays in the
browser. `'$isBase' in X` becomes `X.prototype instanceof Base`, because
v4's brand is a private symbol.

Both export-surface snapshots are regenerated. Every removal is a documented
v2 drop or rename; the additions are the new families, contexts and named
constants — plus sixteen `Data`/`Action` helpers the port exports by name,
which the snapshot now pins for review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
The v2 barrel snapshot pinned 16 helpers as public API that no consumer
asks for: the `Data` form-control internals (`isCheckbox`, `setProperty`,
`valuesEqual`, `readControlValue`, `writeControlValue`,
`serializeControlValue`, `resolvePropertyName`, `isInput`, `isSelect`),
the two expression compilers (`getCallback`, `compileExpression`), the
event-definition parser, and `getEffect`, `getClosestIndex`,
`resolveDetailPlaceholders` and `resolveDataRegistry`.

They reached the surface because the families export them by name for
each other, and the root barrel re-exported those family barrels whole.

The test for public is documentation plus a dedicated subpath, and it
separates the set cleanly: only `withDeprecation` and `withTransition`
are documented, and they are the only two of the eighteen candidates with
a subpath entry of their own. Both stay. So do the eight named constants
(`FETCH_EVENTS`, `HEADER_NAMES`, `INDEXABLE_BOUNDARIES`, …), which exist
so a no-build-step consumer writes `INDEXABLE_BOUNDARIES.loop` instead of
a bare string.

`CompiledExpression` and `ParsedEventDefinition` go too. Each described
the return of a function that is no longer exported, so keeping them left
a type nothing public could produce.

Nothing was reachable by subpath, so no `exports` entry changes. Every
call site imports relatively and is untouched: the type-error count holds
at 52, all of it in the unported `ui-mapbox`, `ui-motion`, `Tabs` and
`CircularMarquee`, and the suite holds at 3 failed / 571 passed — the
three being `Tabs`, which waits on its rewrite.

Both snapshots are regenerated: `index.spec.ts` loses the 16 runtime
values, and `barrel-exports.spec.ts` those plus the two types.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
**Removed.** `Accordion`, `AccordionItem`, the whole `Frame` family, `Modal`,
`ModalWithTransition`, `StyledModal`, `Panel`, `StyledPanel`, the
`ScrollAnimation` family with `animationScrollWithEase` and
`withScrollAnimationDebug`, `ImageGrid`, `Reinsurance`, `withIndex` and
`AbstractSliderChild` are gone from the package, so their Reference items,
stories and catalog entries go with them.

`viewTransition` follows them for a different reason: core absorbed it, so
`@studiometa/ui/scheduler` no longer exists and the helper is imported from
`@studiometa/js-toolkit`. It was the only documented helper, so the sidebar
stops offering an empty group while the overview page keeps its URL and
explains the absence.

**Renamed.** `LazyInclude` → `Defer` with its `defer-content` /
`defer-error` / `defer-always` events, `AnchorScrollTo` → `ScrollTo`, and
`PrefetchWhenOver` → `PrefetchOnInteraction`. Directories, prose, headings,
stories and catalog ids follow; each page carries the old name as an alias so
search still finds it.

The reference catalog and the public contract list are rebuilt against the
real export surface: 78 stale symbols removed, 48 new ones documented, and
every remaining `related` id and cross-link repointed at a page that still
exists. The sixteen internal helpers the port unexported were never
documented, so nothing referenced them.

`packages/docs/scripts/validate-reference.ts` went from 60 errors to 0, so
`npm run docs:build` reaches the playground build again — where it now stops
on `@studiometa/ui-mapbox` and `@studiometa/ui-motion`, whose unported v3
sources import subpaths js-toolkit v4 removed. Both are left out of the
playground bundle until their ports land; the comment naming the condition is
in `packages/playground/meta.config.js`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
Every live example is real component code, so the runtime move broke a set of
them in ways a build does not catch. Each fix below was checked in Chromium.

- **`createApp()` is gone from v4.** The four stories that built an app class
  (`Transition`, `ViewTransition`, `Indexable`, `FigureShopify`) register it
  with `registerComponent()` instead, and their markup gains the
  `data-component="App"` root the registry needs. `$children.X[0]` becomes
  `$query('X')[0]`.
- **`registerComponent()` takes one argument.** The second name/selector
  parameter no longer exists, so `registerComponent(ScrollTo, 'a[href^="#"]')`
  registered nothing: the story's links carry `data-component="ScrollTo"` now.
  The alias form — `registerComponent(FigureShopify, 'Figure')` — becomes a
  subclass which declares `name: 'Figure'`, because a component mounts on its
  configured name and the Twig and Liquid templates write `Figure`.
- **`withResponsiveOptions()` is gone; every option is responsive.** The
  responsive mega-menu drops the decorator, and
  `data-option-mode:xxs:xs:s="click"` becomes the v4 cascade —
  `data-option-mode="click"` with `data-option-mode:m="hover"`. `data-breakpoint`
  goes with it.
- **`@component()` registers its class on import.** `Draggable` is one of the
  twenty-one components that do, so the `dynamic-parent` story's subclass was
  refused by the registry and its `parent` override never ran. It is
  `RingDraggable` now, with its own name.
- **`Transition`'s `group` option has no v4 equivalent** — it collected
  siblings from a global instance registry v4 does not keep. The option, the
  `Group` example and its story are removed, and the `FigureShopify` reveal —
  which used `group` to run two transitions as one — drives the overlay it
  contains through `$query()` instead. Its `img` ref was also prefixed for the
  old class name and never resolved.
- **`Slider`'s `center` mode rejects `contain`**, which the center story asked
  for; it warned eleven times per load. The attribute is dropped.
- **The `Defer` error example could not fail.** It fetched `/404`, and
  `fetch()` resolves for any HTTP status, so the component injected the error
  page's markup. It points at an unresolvable `.invalid` host now, and the
  `error` ref finally shows.
- `FigureVideoTwicpics` included the **image** template, so its markup wrote
  `data-component="Figure"` while the script registered `FigureVideo`. It
  includes `@ui/FigureVideo/FigureVideoTwicpics.twig` now.

`Transition`'s JS API also loses `transition-toggle`, which the mixin does not
emit, and gains the array form of `target` and the optional argument on
`enter()` and `leave()`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
The guide still taught v3's runtime, which the port replaced wholesale.

- **Registering.** `registerComponent()` takes the class and nothing else in
  v4: the alias and CSS-selector second argument is gone, so the guide shows a
  named subclass instead, and notes that twenty-one `@studiometa/ui` classes
  register themselves when their module is imported.
- **`createApp()` does not exist.** "When to use `createApp`" becomes "When to
  use an application component": an app root is an ordinary component with a
  `data-component` on the page root, which is also what scopes its refs.
- **Events bubble**, so the hook section says why each family prefixes its
  event names, and `on<Child><Event>` is documented with the payload v4 hands
  it — `{ target, payload }`.
- **Autoloading was v3 end to end.** `data-load` is `data-mount`, and the four
  strategies are six: `eager`, `visible`, `in-view`, `idle`, `interaction` and
  `media:<query>`, two of them reversible, with the `:<rootMargin>` and
  `:page` suffixes. The `js-toolkit:eager` `<meta>`, `autoload()`,
  `registerManifests()` and the `js-toolkit:error` event are all gone; the
  entry is `registerManifest()` and failures arrive on the
  `js-toolkit:diagnostic` channel with a stable `DIAGNOSTICS` code.

In the Reference, `withMountWhenInView` no longer exists, so the five
components that documented it now name the mount strategy they declare —
`InView` is `in-view`, `InViewOnce` is `visible`, `Figure`, `FigureVideo` and
`AnchorNavTarget` are `in-view`, `LargeText` is `in-view:50%`, `CarouselDrag`
is `media:(pointer: fine)`. `InView` and `InViewOnce` lose their
`intersectionObserver` option with it: the margin belongs to the strategy and
is written as the `data-mount` suffix. `ScrollReveal` keeps its option, and
its page says why — `repeat` picks between the two strategies at runtime, so
it subscribes to the viewport itself.

Every remaining link to the v3 documentation site is repointed at
js-toolkit-v4.studiometa.dev, except in `@studiometa/ui-mapbox`'s pages, whose
sources are still v3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
`docs:build` does not validate a `twoslash` block. A page importing a symbol
`@studiometa/ui` does not export builds clean, with no error marker and an
empty hover — the transformer is running (the popper markup is in the output)
but every hover it emits is empty and no diagnostic reaches the page.
Twoslash itself is fine: run directly, it reports
`Module '"@studiometa/ui"' has no exported member` for the same snippet. So
the samples below were wrong for as long as the workspace has been on v4 and
nothing said so.

Eight pages still handed the reader `createApp()`, `$children` or the
two-argument `registerComponent()`. `Figure`, `FigureShopify`,
`FigureTwicpics`, `FigureVideo`, `FigureVideoTwicpics` and `ScrollTo` show the
named subclass a renamed `data-component` needs; `Indexable` and `Slider` show
`registerComponent()` with the `data-component="App"` root that scopes an
application component's refs. `Slider`'s two pages drop the subclass entirely:
its optional controls are registered like any other component.

`Toaster` described `Timer` cancelling its countdown from `destroyed()`, which
v4 removed; the cleanup returned by `mounted()` does it.

Every one of the 21 remaining `twoslash` blocks was type-checked by hand
through the twoslasher, and all 21 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
Declare the playback and transition events in the props types now that
`config.emits` is gone, replace `$children.Motion` with live
`$watchChildren()` collections, and move the teardowns off `destroyed()`
— onto the cleanup `mounted()` returns where it pairs with its setup,
onto `unmounted()` where it does not.

`addClass`/`removeClass` do not exist in v4: both were one-line wrappers
over `classList`, so `MotionView` splits its space separated option
value itself, exactly as the ported `ViewTransition` does.

`$warn()` now takes a namespaced diagnostic code, and `$emit()` carries
one named payload object instead of a spread `detail` array — which also
retires `Motion.__dispatch()`, a hand-rolled bubbling `CustomEvent`,
since v4's `$emit()` already bubbles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
Declare every map event in the props types now that `config.emits` is
gone. A v4 payload is one named object rather than a spread `detail`
array, so the values that used to travel by position are read by name:
`detail.map`, `detail.error`, `detail.items`, `detail.clusterId`.

Drop `withExtraConfig()` from the three controls: it existed because v3
did not merge config along the prototype chain, and v4 does, so each
control is a plain subclass with its own `static config`. The same change
retires the `name`/`debug`/`log` filtering in `AbstractMapboxControl`,
since v4 defines none of those built-in options.

Replace `StoreLocator`'s bounded `nextTick` retry loop — v4 exports no
`nextTick` — and its `MAPBOX_CLUSTER_CONNECTED` subscription with two
`$watchChildren()` collections. Both existed to answer "has the cluster
mounted yet?", which a live collection answers with nothing to poll.
`MAPBOX_CLUSTER_CONNECTED` stays for `MapboxClusterItem`, which looks
*up* for its cluster, and v4 has no watching counterpart to `$closest()`.

`destroyed()` becomes `unmounted()`, and `$warn()` takes a namespaced
diagnostic code — with the contained failures moving to `$error()`,
which carries the cause a warning had nowhere to put.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
The build emits stage-3 decorator syntax verbatim — Oxc does not lower it —
so 21 of the 117 modules in the published `dist/` were unparseable and threw
`SyntaxError` on import, in Node as well as in browsers. Rewrite every
decorator call to its plain equivalent:

- `@component({ … })` becomes `static config`, which also drops the
  `registerComponent(this)` the decorator ran on the side. Registration is now
  the consumer's job for every component in the package, exactly as it already
  was for the 60-odd components that never carried the decorator; the
  autoloader registers each class itself once its manifest entry resolves.
- `@on('click' | 'submit' | 'scroll')` becomes `onClick`, `onSubmit` and
  `onScroll`, and `@on(window, 'popstate')` becomes `onWindowPopstate` — the
  name `FetchShopifySection` already referred to in a docblock.
- `Defer`'s three `@on('defer-content' | 'defer-error')` handlers keep their
  descriptive names and are subscribed from `mounted()`, whose returned
  releases own them: the naming convention cannot spell a hyphenated event.
- `@provide()` becomes `$provide()` and `@children()` becomes
  `$watchChildren()`, matching `Slider` and `Menu`.
- `@write` becomes an explicit `$write()` call around the method body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
`@rollup/plugin-swc` and `@swc/core` were added only because Oxc cannot lower
stage-3 decorators, and no source in the repository uses one any more —
`packages/ui`, `packages/ui-mapbox`, `packages/ui-motion` and the specs
themselves are all decorator-free, and `@studiometa/js-toolkit` ships built
JavaScript. Vite's own transformer is enough, so the plugin, its filter and
both dependencies go.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
Two pages said some `@studiometa/ui` classes register themselves on import,
because they carried `@component()`. None does now, so the asymmetry a reader
could not see is gone: importing defines a class, registering is what makes it
mount, and the autoloader is the alternative that does both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
Their v3 sources imported subpaths js-toolkit v4 removed, which is a hard
resolve error for the bundler and failed `docs:build` outright. Both are
ported, so the exclusion — and the comment naming the condition — go with
it. This restores the 32 stories the two packages own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
`importWhenVisible` and its siblings do not exist in js-toolkit v4:
lazy registration is a manifest entry — a `load` importer plus a
`mountStrategy` the element can override with `data-mount`. Every
lazy-loading section is rewritten around `registerManifest()`, and the
packages' own `./manifest` subpath is named as the shortest way in.

The documented event payloads follow the runtime: a v4 `$emit()` carries
one named object, so `map-load` is `{ map }`, a forwarded mapbox event is
`{ event }`, `map-item-click` is `{ item, feature, event }`, and so on.
Delegated handlers read `{ payload }` rather than v3's `{ args: [...] }`,
and the `Motion`/`Timer` story reads `event.detail.ratio`.

`createApp()` is gone too, so the remaining examples register their
application component like any other, and the prose stops promising a
`$query` retry loop `StoreLocator` no longer runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
v4 guarantees no mount ordering and `$watchChildren()` seeds its
collection in a microtask, so `MotionSequence` and `MotionScrollTimeline`
could reach `mounted()` with an empty collection and silently skip the
autoplay or the scroll links. Both now await `whenDOMSettled()` first,
which is the guarantee v3 got for free by having a parent construct its
own children.

`StoreLocator` gains the same correction from the other side: a cluster
that wires before the map has loaded gets a `__refresh()` with no map to
fit or filter against, so the map-load path refreshes unconditionally
rather than only on a first wire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
`StoreLocator.mounted()` read the `mapboxMap` getter, which warned when
`$query('MapboxMap')` was empty — and v4 mounts a wrapper before the
elements inside it, so every healthy locator reported
`store-locator.no-map` on every page load and recovered silently a moment
later through its `MAPBOX_MAP_CONNECTED` subscription.

The getter no longer warns. A locator with genuinely no map in its markup
is reported once, after `whenDOMSettled()`, and judged on the *element*
rather than the mounted instance: DOM ancestry is a fact that exists
before anything mounts, so the check is immune both to mount ordering and
to the map's mount strategy.

Also corrects the docblocks the port made wrong — the containment routes
to `$error()` now, not `$warn()`, and `destroyed()` is `__onDestroyed()`
— and records why `unmounted()` deliberately keeps `__readyMap`, which an
in-flight `MapboxImage` injection depends on to undo itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
Both suites failed at collection, so none of their 226 tests ran. They
are registry-driven now: v3 built a fake `MapboxMap` per test and stubbed
`instance.$closest`, while v4 mounts the real family through
`registerComponents()` and `mount()` and lets each child resolve its own
parent. `MapboxMap/harness.ts` holds what every spec needed — an unloaded
map wrapping given markup, a `load()` that fires `map-load` once the test
has configured the double, and an `append()` that waits for the observer.

The mechanical half: `$destroy()` is `$unmount()`, event payloads are read
by name instead of by position, and `$warn`/console spies become
`captureDiagnostics()`, which reads the channel rather than one of its
sinks.

Two assertions could not survive `config.emits` and are re-expressed
against what the components do. `@studiometa/ui-motion` drives a full
playback lifecycle and asserts the five names it observed. The mapbox
suite pins each component's event set with an exhaustive
`Record<EmitNames<Props>, true>`, so adding or removing an event in the
source is a compile error, and asserts the `map-` prefix at runtime.

Every file keeps its exact test count. Nothing is deleted and nothing is
skipped; the three remaining failures are the deferred `Tabs` rewrite.

`autoload/manifest.spec.ts` was asserting a v3 manifest shape it never
got to run — `token`, `packageName` and `strategy` are gone from a v4
entry — so it now reads `mountStrategy` and checks the loaded class
against the key the registry resolves it by, with `ui-motion` added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
titouanmathis and others added 2 commits August 28, 2026 12:25
The default snippet the editor loads still imported and called
`createApp()`, which v4 removed, so every visitor's first playground
session started on a dead API.

`registerComponent()` alone would have been a worse trap. In v4 an
instance exists because its element is in the document *and* its class is
registered, so registering `App` against markup that never declares it
produces a class that mounts nothing, with no error — which is exactly
how the three playground editor subclasses and the 21 self-registering
`@studiometa/ui` components were nearly lost. The default markup now
declares `data-component="App"`.

Verified by loading the default session at /play/ against a locally built
`@studiometa/playground` 0.4.0: the preview iframe completes, the element
carries `data-component="App"`, and the console reports `getDefault
script` with no errors.

The comment about js-toolkit's registry is reworded for the same reason;
it named `createApp` as the thing holding the mutable state.

Note that the site's playground shell stays broken until
`@studiometa/playground` 0.4.0 is released — studiometa/playground#79.
That is a version bump here, not a change to this file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
exportName: 'CarouselWrapper',
strategy: 'eager',
group: 'carousel',
mountStrategy: 'eager',
load: () =>
import('./Carousel/CarouselWrapper.js').then(({ CarouselWrapper }) => CarouselWrapper),
},
CircularMarquee: {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (blocking): Remove the unported CircularMarquee from the v4 manifest

CircularMarquee.ts still imports @studiometa/js-toolkit/utils/damp and @studiometa/js-toolkit/utils/transform and still uses the v3 props.delta.y and ticked() APIs. Those v3 subpaths are removed by the v4 migration, so resolving this manifest entry attempts to load a component that cannot be imported. Port CircularMarquee to v4 before adding it to the manifest, or omit it from the v4 manifest until that port lands.

Confidence: high.


Reviewed by @weareikko/code-review v0.9.5 for commit 5817b7c.

strategy: 'eager',
group: 'sticky',
children: ['Sentinel'],
mountStrategy: 'eager',
load: () => import('./Sticky/Sticky.js').then(({ Sticky }) => Sticky),
},
Tabs: {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Remove the unported Tabs from the v4 manifest

Tabs.ts still imports @studiometa/js-toolkit/Base and @studiometa/js-toolkit/utils/transition, and still declares config.emits, all of which are v3-era APIs while the workspace now resolves js-toolkit v4. Resolving this manifest entry therefore fails when the runtime loads Tabs; port it to v4 or remove the entry until the deferred rewrite is complete.

Confidence: high.


Reviewed by @weareikko/code-review v0.9.5 for commit 5817b7c.

`@studiometa/playground` 0.3.13 was a js-toolkit v3 application: its shell
called `createApp()`, which v4 removed, so the shell threw before it
rendered anything and no example on this site executed. 0.4.0 is the v4
migration of that package — studiometa/playground#79.

This is the whole of the fix on our side. The version conflict resolves
itself: 0.3.13 declared `^3.4.3` against a workspace pinned to
4.0.0-alpha.1, which npm reported as an invalid tree, and 0.4.0 declares
`^4.0.0-alpha.1`. `npm ls @studiometa/js-toolkit` is clean.

Verified in a browser through ddev against the published package, not a
local build: the Disclosure examples page mounts three `Disclosure`
instances, clicking the second moves the group from
`["true","false","false"]` to `["false","true","false"]`, and the console
carries no errors where it used to report `createApp is not a function`
plus three follow-on failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant